package it.hakvoort.scripted;

import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.net.URLConnection;
import java.util.Arrays;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Set;
import java.util.WeakHashMap;

import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

/**
 * The ScriptedProvider is the responsible entity for proxying interfaces
 * to backing script files. All loaded script files will be made available
 * under a (per script type) shared context. When all scripted instances
 * are no longer in use, a garbage collect can free the resources associated
 * with the scripted instances.
 * 
 * @author Michiel Hakvoort <michiel@hakvoort.it>
 */
public class ScriptedProvider {

	/**
	 * The shared ScriptEngineManager instance
	 */
	protected ScriptEngineManager engineManager;

	/**
	 * Cached engines
	 */
	protected WeakHashMap<ScriptEngineFactory, ScriptContext> cachedEngines;

	/**
	 * Cached resources
	 */
	protected WeakHashMap<ScriptContext, Set<URL>> loadedResourcesByContext;

	/**
	 * Instantiate a ScriptedProvider with the current context ClassLoader.
	 */
	public ScriptedProvider() {
		this(Thread.currentThread().getContextClassLoader());
	}

	/**
	 * Instantiate a ScriptedProvider with the given backing ClassLoader for
	 * the internal ScriptEngineManager.
	 * 
	 * @param classLoader The backing ClassLoader for the ScriptEngineManager  
	 */
	public ScriptedProvider(ClassLoader classLoader) {
		this(new ScriptEngineManager(classLoader));
	}

	/**
	 * Instantiate a ScriptedProvider with the given ScriptEngineManager, which
	 * will be used for ScriptEngine resolution.
	 * 
	 * @param engineManager The backing ScriptEngineManager for the ScriptedProvider
	 */
	public ScriptedProvider(ScriptEngineManager engineManager) {
		this.engineManager = engineManager;
		this.cachedEngines = new WeakHashMap<ScriptEngineFactory, ScriptContext>();
		this.loadedResourcesByContext = new WeakHashMap<ScriptContext, Set<URL>>();
	}

	/**
	 * Resolve a ScriptEngine by mime type. If a ScriptEngine is resolved
	 * the engine's context is shared for all ScriptEngines of the same type.
	 * 
	 * If no ScriptEngine can be found, null is returned.
	 * 
	 * @param mimeType The mime type to resolve a ScriptEngine for
	 * @return The resolved ScriptEngine, or null if no matching ScriptEngine could be found
	 */
	protected ScriptEngine getEngineByMimeType(String mimeType) {
		ScriptEngine engine = engineManager.getEngineByMimeType(mimeType);

		if(engine == null) {
			return engine;
		}

		shareEngineContext(engine);


		return engine;
	}

	/**
	 * Resolve a ScriptEngine by filename. If a ScriptEngine is resolved
	 * the engine's context is shared for all ScriptEngines of the same type.
	 * 
	 * If no ScriptEngine can be found, null is returned.
	 * 
	 * @param fileName The fileName to resolve a ScriptEngine for
	 * @return The resolved ScriptEngine, or null if no matching ScriptEngine could be found
	 */
	protected ScriptEngine getEngineByFilename(String fileName) {
		if(fileName.lastIndexOf('.') == -1) {
			return null;
		}

		String extension = fileName.substring(fileName.lastIndexOf('.') + 1);

		ScriptEngine engine = engineManager.getEngineByExtension(extension);

		if(engine == null) {
			return engine;
		}
		
		shareEngineContext(engine);

		return engine;

	}

	/**
	 * Share the ScriptEngine's ScriptContext for all scripts of ScriptEngine's
	 * type within the ScriptedProvider.
	 * 
	 * @param engine The ScriptEngine for which the ScriptContext needs to be shared
	 */
	protected synchronized void shareEngineContext(ScriptEngine engine) {
		ScriptEngineFactory factory = engine.getFactory();

		if(cachedEngines.containsKey(factory)) {
			engine.setContext(cachedEngines.get(factory));
		} else {

			ScriptContext context = engine.getContext();
			this.loadedResourcesByContext.put(context, new HashSet<URL>());

			cachedEngines.put(factory, context);
		}
	}

	/**
	 * Load the given script file within the associated ScriptContext for
	 * the script file type. If the associated ScriptContext is not strongly 
	 * referenced prior to calling this method, the ScriptContext in  which
	 * the given script file will be loaded might be garbage collected
	 * (directly) after the script file has been loaded.
	 * 
	 * @param scriptFile The script file to load
	 * @throws ScriptException
	 */
	protected void load(String scriptFile) throws ScriptException {

		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

		URL scriptResource = classLoader.getResource(scriptFile);

		if(scriptResource == null) {
			throw new ScriptException("Resource \"" + scriptFile + "\" not found");
		}

		load(scriptResource);
	}

	/**
	 * Load the script file at the given location within
	 * the associated ScriptContext for the script file type.
	 * If the associated ScriptContext is not strongly referenced
	 * prior to calling this method, the ScriptContext in  which
	 * the given script file will be loaded might be garbage collected
	 * (directly) after the script file has been loaded.
	 * 
	 * @param resource The script file to load
	 * @throws ScriptException
	 */
	protected void load(URL resource) throws ScriptException {
		URLConnection connection;

		try {
			connection = resource.openConnection();
		} catch(IOException e) {
			throw new ScriptException(e);
		}

		String contentType = connection.getContentType();

		ScriptEngine engine = getEngineByMimeType(contentType);

		if(engine == null) {
			String fileName = resource.getFile();

			engine = getEngineByFilename(fileName);

			if(engine == null) {
				throw new ScriptException("Script type cannot be determined");
			}
		}

		if(!(engine instanceof Invocable)) {
			throw new ScriptException("ScriptEngine not invocable");
		}

		Set<URL> loadedResources = loadedResourcesByContext.get(engine.getContext());

		/*
		 * Load resources only once
		 */
		synchronized(this) {
			if(loadedResources.contains(resource)) {
				return;
			}
	
			try {
				engine.eval(new InputStreamReader(connection.getInputStream()));
				loadedResources.add(resource);
	
			} catch (IOException e) {
				throw new ScriptException(e);
			}
		}
	}

	/**
	 * Get a scripted instance of an interface. If the given class is not
	 * an interface or is not annotated with a ScriptBase, this method
	 * will throw a ScriptException.
	 * 
	 * @param <T>
	 * @param clazz The interface to retrieve a scripted instance for
	 * @return An instance of clazz, implemented by a script file
	 * @throws ScriptException
	 */
	public <T> T getScripted(Class<T> clazz) throws ScriptException {
		if(!clazz.isInterface()) {
			throw new ScriptException("Class \"" + clazz.getCanonicalName() + "\" is not an interface");
		}

		if(!clazz.isAnnotationPresent(ScriptBase.class)) {
			throw new ScriptException("Class not annotated with @ScriptBase");
		}

		String scriptFile = clazz.getAnnotation(ScriptBase.class).value();

		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

		URL resource = classLoader.getResource(scriptFile);

		if(resource == null) {
			throw new ScriptException("Resource \"" + scriptFile + "\" not found");
		}

		return getScripted(clazz, resource);

	}

	/**
	 * Get a scripted instance of an interface. Scripted by the given
	 * script file. If the given class is not an interface this method
	 * will throw a ScriptException.
	 * 
	 * @param <T>
	 * @param clazz The interface to retrieve a scripted instance for
	 * @param resource The location of the script file which provides the
	 * 					object implementing the given interface.
	 * @return An instance of clazz, implemented by a script file
	 * @throws ScriptException
	 */
	public <T> T getScripted(Class<T> clazz, URL resource) throws ScriptException {
		if(!clazz.isInterface()) {
			throw new ScriptException("Class \"" + clazz.getCanonicalName() + "\" is not an interface");
		}
		URLConnection connection;

		try {
			connection = resource.openConnection();
		} catch(IOException e) {
			throw new ScriptException(e);
		}

		String contentType = connection.getContentType();

		/*
		 * Make sure there is a ScriptEngine
		 */
		ScriptEngine engine = getEngineByMimeType(contentType);

		if(engine == null) {
			String fileName = resource.getFile();

			engine = getEngineByFilename(fileName);
			
			if(engine == null) {
				throw new ScriptException("Script type cannot be determined");
			}
		}

		if(!(engine instanceof Invocable)) {
			throw new ScriptException("ScriptEngine not invocable");
		}

		Deque<Class<?>> loadableInterfaces = new LinkedList<Class<?>>();

		Queue<Class<?>> interfaces = new LinkedList<Class<?>>();

		interfaces.add(clazz);

		/*
		 * Do a breadth-first search to all the parenting interfaces
		 */
		do {
			Class<?> currentInterface = interfaces.poll();

			loadableInterfaces.offer(currentInterface);

			interfaces.addAll(Arrays.asList(currentInterface.getInterfaces()));
		} while(!interfaces.isEmpty());

		Set<Class<?>> loadedInterfaces = new HashSet<Class<?>>();

		/*
		 * Traverse the loadable interfaces in reverse order to load root interfaces (and dependencies)
		 * first.  
		 */
		while(!loadableInterfaces.isEmpty()) {
			Class<?> loadableInterface = loadableInterfaces.removeLast();

			if(loadedInterfaces.contains(loadableInterface)) {
				continue;
			}

			/*
			 * Mark the interface as loaded
			 */
			loadedInterfaces.add(loadableInterface);

			/*
			 * Load all required scripts for this interface
			 */
			if(loadableInterface.isAnnotationPresent(ScriptRequired.class)) {
				for(String requiredScript : loadableInterface.getAnnotation(ScriptRequired.class).value()) {
					load(requiredScript);
				}
			}

			/*
			 * Do not include ScriptBase annotation for current class
			 */
			if(loadableInterface == clazz) {
				continue;
			}

			/*
			 * Load super interface ScriptBase annotations
			 */
			if(loadableInterface.isAnnotationPresent(ScriptBase.class)) {
				load(loadableInterface.getAnnotation(ScriptBase.class).value());
			}
			
		}
		
		Object value;

		try {
			value = engine.eval(new InputStreamReader(connection.getInputStream()));
		} catch (IOException e) {
			throw new ScriptException(e);
		}

		if(value == null) {
			throw new ScriptException("Interface not representable by empty script object");
		}

		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

		/*
		 * Create the proxy
		 */
		@SuppressWarnings("unchecked")
		T result = (T) Proxy.newProxyInstance(classLoader, new Class<?>[]{clazz}, new ScriptableInvocationHandler((Invocable) engine, value));

		return result;
	}

}
